Skip to content

Stacks, display Modes, takeovers, and moving widgets between tabs - #378

Merged
jherforth merged 13 commits into
jherforth:mainfrom
jasonstantler:feature/stacks-and-modes
Nov 6, 2026
Merged

jherforth merged 13 commits into
jherforth:mainfrom
jasonstantler:feature/stacks-and-modes

Conversation

@jasonstantler

Copy link
Copy Markdown
Contributor

Three things that work together on a wall display, all tested on a portrait touch screen.

Stacks

Several widgets in one spot, swiped between, while the rest of the dashboard stays put (like an iPhone Smart Stack).

  • Make one in Move/Resize by holding a widget over another, or under Admin → Widgets → Stacks (which also picks the tabs it shows on).
  • A quick sideways flick turns it, including on plugins (the frame's touch script relays a flick only while the plugin is in a Stack). Dots and arrows show on touch, then fade.
  • Goes back to its first widget after a while untouched. Bring to front when… shows a widget while a Home Assistant entity is (or isn't) in a state, e.g. a grill card while the grill is on.
  • Widgets shown in a placed Stack no longer count as "enabled but on no tab", so Admin doesn't ask to add them again.

Move/Resize

  • Drag a widget onto a dock tab, hold it at the screen edge, or drop it on +, to carry it to another tab (phone style).
  • Long-press a widget: Move to tab, Duplicate to tab (or a new tab), Widget settings, Remove from dashboard (asks, then Undo). On a Stack: the showing widget's settings, the others', Stack settings, Unstack.

Display Modes and takeovers (Admin → Displays → Modes)

  • A mode (Morning, Dinner, Night…) turns on by schedule or from Home Assistant (a phone's next alarm, an entity's state, only while someone is home). It can start on a tab, dim the screen to off, and change what each Stack shows. Nothing on the dashboard moves.
  • A small label beside the dock pins or switches modes. HomeGlow can create a dropdown helper in Home Assistant (Automatic, Normal and the modes) kept in sync both ways, and report the showing mode as a sensor.
  • Takeovers: a widget can take over the top or bottom half, or the whole screen, while a Home Assistant rule is on, then hand the wall back. Plugins can raise or close their own with HomeGlow.attention() (documented in the plugin guide).

API

  • GET /api/devices/:name/stacks/ha and /stacks/ha/entities
  • /api/devices/:name/modes/ha (states, entities, helper, sync, select, report)
  • PUT /api/devices/:name/widget-assignments/entry (Undo for Remove)

Tests

Client (Vitest, 972 passing) and check:i18n (English and Spanish). Server route tests for both Home Assistant reads, the restore-entry route and the flick relay. The four googleTimeouts tests need internet access, which my test sandbox does not have; they are unrelated to this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3

Stacks: several widgets in one spot, swiped between, while the rest of the
dashboard stays put (like an iPhone Smart Stack).
- Make one on the wall (Move/Resize: hold a widget over another) or under
  Admin -> Widgets -> Stacks, which also sets the tabs it shows on.
- A quick sideways flick turns it, also inside plugin frames (the frame's
  touch script relays a flick only while the plugin is in a Stack). Dots
  and arrows show on touch, then fade.
- Goes back to its first widget after a while untouched, and "Bring to
  front when..." shows a widget while a Home Assistant entity is (not) in
  a state (GET /api/devices/:name/stacks/ha).
- Widgets shown in a placed Stack no longer count as "enabled but on no
  tab" in Admin, so saving doesn't ask to add them again.

Move/Resize:
- Drag a widget onto a dock tab, or hold it at the screen edge, to carry
  it to another tab (phone style), or onto + for a new tab.
- Long-press a widget: Move to tab, Duplicate to tab (or a new tab),
  Widget settings, Remove from dashboard (asks, then Undo). On a Stack
  also: the showing widget's settings, the others', Stack settings,
  Unstack. PUT /widget-assignments/entry restores a removed entry.

Display Modes (Admin -> Displays -> Modes): a wall display changes through
the day without anything moving. A mode turns on by schedule, or from Home
Assistant (a phone's next alarm, an entity's state, only while someone is
home); it can start on a tab, dim the screen to off, and change what each
Stack shows (which widgets, which first, go-back time, cycle through
widgets, pause Bring to front). A mode label beside the dock pins or
switches modes. HomeGlow can make a dropdown helper in Home Assistant
(Automatic, Normal and the modes) kept in step both ways, and report the
mode showing as a sensor.

Takeovers: a widget can take over the top or bottom half, or the whole
screen, while a Home Assistant rule is on, then hand the wall back. Two can
share the screen; plugins can raise or close their own with
HomeGlow.attention().

Docs: README features, frontend reference, plugin guide (takeovers).
Tests: client (Vitest) and server routes for both Home Assistant reads,
the restore-entry route and the flick relay.
@jherforth jherforth added this to the 2.0 milestone Nov 4, 2026
@jherforth jherforth added the enhancement New feature or request label Nov 4, 2026
@jherforth

Copy link
Copy Markdown
Owner

@jasonstantler drap and drop to tab has some hitches:

Test 1

  1. I created a stack and added a new tab
  2. I enabled layout edit and then dragged to the tab I just created and the stack's edit window is stuck on the screen and requires a page refresh to fix it.
Screenshot 2026-11-03 202047

Test 2

  1. I purposefully did not create a new tab and edited the layout
  2. I dragged a widget to the "+" to create a new tab which crashed the app
Screenshot 2026-11-03 201843 3. The widget just needs the tab reset in admin panel when it panics. I also saw it work correctly, where it prompted for a tab create and moved automatically to the new tab. Most of the time it just drops the tab from the widget and it needs to be reapplied.

Stacks seem to work well.
Haven't tried modes but it sounds neat. I think if we can tighten up drag and drop we can merge it in.

jasonstantler and others added 4 commits November 4, 2026 01:44
Modes no longer rearrange the dashboard; comments and a test name still
talked about mode layouts and the layout editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
Over the dock (a tab or +), a drag counted as hovering a widget lying
underneath it: let go there and the two were stacked instead of the widget
moving, so it seemed to vanish from its tab. A Stack target is now only the
topmost widget under the finger, never past the dock, the grid's drop
shadow or the dragged widget; "Stack with..." clears when a widget is
carried to another tab.

Moving or duplicating to another tab is one server write
(POST widget-assignments/transfer, which also takes where it lands), so a
widget is never left on no tab and keeps its per-tab settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
The drop outline jumped a whole grid cell in one frame (transitions are
off while a widget is selected); it now glides, as do the carry and
"Stack with..." outlines. While "Stack with..." shows, the grid's drop
outline hides, so only one outline is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
…herforth#185)

Making a Stack with Weather in Move/Resize threw "Maximum update depth
exceeded" from the forecast chart: its data was a new array on every
render, and the chart's store updated over and over. The chart data and
its dot style are now stable between renders.

Also: hidden Stack pages are inert (inert="" was read as false in
React 19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
@jherforth

Copy link
Copy Markdown
Owner

@jasonstantler still a little buggy:

  1. When I dragged the calendar to the bar to create a tab, it prompted, I created and it moved, as as expected, but during the same layout edit session I also moved two widgets on the home screen and repositioned them. Went back to the newly created tab to confirm that position looked good, and it did, so I saved the layout. I went back to the home tab the moved widgets went back to their old positions. Before modifying positions and saving again I just refreshed the page and they adopted the positions I had moved them to during the first layout edit, as expected, so the data is being saved and on refresh adopts the position, it just doesn't show the new positions unless the page is refreshed. That could be a data/timing issue or a purely client side issue because on refresh things were they should have been. If we can get that sorted then I'll merge.
  2. Stacks seem to be working great.

jasonstantler and others added 2 commits November 4, 2026 15:20
…witch

Positions saved from Move/Resize reached the server but not the
dashboard's own copy, which the grid redraws a tab from when you come
back to it, so they showed in their old spots until a refresh. The grid
now hands the saved rows to app.jsx, which updates its copy right away.

Also: a save still waiting for one tab is sent instead of dropped when
another tab saves within the half second, and a save landing mid-drag
doesn't redraw the tab under the finger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
…takeovers redraw only on change

The Add devices box got a new empty list on every redraw, which it read as a
new pick and cleared the typing. Takeovers re-checked their rules every second
and handed back a new answer each time, rebuilding every widget; the answer is
now kept unless something changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jherforth

Copy link
Copy Markdown
Owner

@jasonstantler when this is good, have claude rebase so the conflicts are resolved and then we can get it in.

@mrramam

mrramam commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

This is a neat addition!

A few issues that came up on a "take a quick look at this PR" on Claude.

  1. Stacking deletes the members' tab entries. stackWidgets (and placeOn in StacksSettings) delete ${tab}::<widget>, but that entry holds more than position: Weather's location, unit and layout, Calendar's view mode and week start. Inside the Stack, Weather falls back to defaults, and saving its settings again 404s ("Assignment not found") because the entry is gone. Unstacking posts fresh entries with null layout, so the settings are lost for good, with no undo.

  2. Ordinary Move/Resize drags now carry or stack by accident. Releasing in the bottom rows near the dock moves the widget to the nearest tab (pickDockTarget), resting within EDGE_PX of a screen edge for CARRY_DWELL_MS switches tabs with the widget in hand, and resting over another widget for 550ms makes a Stack on release. Placing a widget in the first or last column or the bottom row hits these easily.

  3. droppedAwayRef can stay set and drop the next save. With collisions prevented, a drag onto another widget (or onto a dock tab with the cell underneath taken) never moves, so onLayoutChange never fires and the flag is only cleared by the next saveLayoutsToApi. That next real rearrangement is discarded and snaps back on reload.

  4. Rolling back hides stacked widgets. Members are removed from config_json and only stack:<id> plus widgetStacks record them, so any build without this PR shows none of them while they stay enabled.

  5. A late alarm turns a mode on for up to 12 hours. alarmWindow wraps to the next until when the alarm rings after it (Morning until 09:00, alarm at 09:30, on until 21:30), and automaticModeAt checks alarms before schedules, so an evening alarm overrides Night. A ring after until should open no window.

Also worth a look:

  • One failed HA poll in useDisplayModes sets states: {}, and stepRules forgets every rule, so dismissed takeovers come back and urgent ones wake and chime again.
  • Repeated attention('urgent') renews at each call, so it chimes every time and the dismissal never sticks.
  • The HA polls run on a plain setInterval with no in-flight guard, and errors aren't cached, so requests pile up while HA is down.

For the rebase onto #375: the new writers (moveWidgetToTab, stackWidgets, unstackOnTab, confirmRemoveWidget, undoRemoveWidget) capture API_DEVICE_URL at render time across awaits, so they should resolve the device at call time the way fetchWidgetAssignments now does. onLayoutsSaved running before the PATCH lands would also drop the touched ids early in reconcileLayoutWithLive and reopen the snap-back that #375 fixed.

claude and others added 3 commits November 4, 2026 23:49
- Stacking keeps the widgets' tab entries, marked in_stack, so their
  per-tab settings stay (Weather saves no longer 404) and come back when
  unstacked; Remove takes them along and Undo puts them all back. New
  PATCH widget-assignments/in-stack; older Stacks are repaired once.
- Admin's Stacks and tab pickers mark/unmark instead of deleting.
- Unstacking puts widgets back where they were when that spot is free.
- Drags: the dock counts only on its buttons, the side of the screen only
  after moving toward it and a longer wait, and stacking takes a resting
  hold on the middle of the other widget.
- An alarm that rang after "until" turns no mode on.
- Home Assistant: one read at a time, backing off on failures; a short
  blip keeps the last states; takeovers forget nothing while it's away.
  The server remembers a failed read briefly and shares reads.
- A plugin repeating the same attention() changes nothing: no second
  chime, and a closed takeover stays closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ3CQ7VFGMpS8JsXcc3
@jherforth

Copy link
Copy Markdown
Owner

@jasonstantler Thanks, this is close. On the latest head (12b25f5), server tests (745), client tests (1018), check:i18n and the build all pass. In Edge, against a real server:

  • The snap-back from my second comment is fixed: two widgets moved on Home, a visit to the new tab and Save, then back to Home leaves them where they were moved. The server and a reload agree.
  • Dropping a widget on + opens the new-tab dialog and moves it into the new tab, with no errors.
  • A drop just above the dock stays on its tab. A Stack keeps its widgets' entries (marked in_stack), and when it's on screen at page load it moves to another tab with them.

Two things need fixing before merge:

1. A Stack made by holding one widget on another breaks the page until a refresh. This is my Test 1 from the first comment. Steps:

  1. In Move/Resize, hold Photos on Chores (or Photos on Calendar) until "Stack with…" shows, then let go.
  2. The console logs "Maximum update depth exceeded", and it keeps happening (1, then 3, then 9 errors over a few seconds).
  3. The Stack grows on screen (405px wide, then 607px, then 1102px, at an x that doesn't line up with the grid), and a widget card is left floating over its neighbour.
  4. Add a tab with + and drag the Stack onto it in the dock. Nothing happens and no request is sent. Sometimes a tap no longer selects the Stack at all.

After a refresh, the same Stack selects, drags to the other tab and takes its widgets along, with no errors. So the trouble is in the session where the Stack was made. The loop shows up in Chores' useEdgeFade ResizeObserver, but that's only where React notices it: the widget keeps re-rendering because the grid around it keeps changing size. My guess is the grid's own layout still has the dragged widget at its last drag position after it went into the Stack, and that fights the new widgets list. Worth checking alongside droppedAwayRef.

2. The display-mode Home Assistant routes can write to any input_select in Home Assistant, with no PIN. The server doesn't check the admin PIN on these routes, and plugin frames can't reach them, but any browser on the network can:

  • POST /api/devices/<any>/modes/ha/helper with { "entity_id": "input_select.<anything>" } runs set_options on that helper, replacing its options with Automatic/Normal/the modes;
  • PATCH /api/devices/<name>/settings can set displayModes.ha.helper (or helperId) to any helper. After that, /modes/ha/sync rewrites its options and /modes/ha/select sets its state.

An input_select often drives household automations (house mode, alarm mode), so this lets a kid's tablet change them. HomeGlow's other Home Assistant writes go through each plugin's scoped access, and unlocking or disarming needs the PIN. Suggested fix:

  • Make displayModes.ha server-owned: the settings PATCH keeps the stored ha.helper/helperId and ignores incoming values.
  • Have /modes/ha/helper check the admin PIN the way /api/plugin/v1/ha/:pluginId/action does: checkAdminPin, unless the display remembers it.
  • Also, /modes/ha/helper calls writeDevice → ensureDeviceExists, so it creates a device row for an unknown name. Return 404 instead, as the viewport route from feat(mirror): remote-control mirror mode to configure another display #375 does.

The sensor that /report posts to is limited to sensor.homeglow_*_showing, which is fine.

Small and unrelated: under npm run dev, react-grid-layout's prebundled react-draggable reads process.env.DRAGGABLE_DEBUG, so any drag throws "process is not defined" in the dev server. Production builds are fine. This happens on main as well, not because of this PR.

Stacks and the Move/Resize changes otherwise look good. Once 1 and 2 are fixed, I'll recheck and merge.

🤖 Generated with Claude Code

jasonstantler and others added 3 commits November 5, 2026 05:18
Stack writes are now one write each on the server, so a display never sees
a Stack without its widgets (server/utils/stackEntries.js):
- Moving or duplicating a Stack carries its widgets in one transaction
  (transfer with members/merge). Before, a refresh mid-move ran the repair
  and split the Stack: widgets popped back on the old tab, blank copies on
  the new one. The saved Stack decides which widgets go, not the list the
  display sends; a widget still marked but no longer in it stays, unmarked.
- Removing a Stack takes its widgets in the same write (?members=1) and
  returns what it took; Undo puts it all back in one write (entries), and
  only what isn't on the tab again since (onlyMissing).
- Unstacking is one write (new POST widget-assignments/unstack).
- A new Stack is placed and its widgets marked in one write (in-stack place).
- Deleting a tab keeps widgets in their Stack when it lands on Home too.

Undo:
- Notices are queued (utils/undoNotices.js): another notice no longer
  throws away a pending Undo.
- A remove that timed out but happened still offers Undo.
- A double tap undoes one removal, not two.
- Undo turns the widget back on if a removal turned it off.

Admin -> Stacks no longer deletes widgets' entries: taken out of a Stack,
a widget with a spot or settings comes back on its own (in-stack release,
unstack dropBlank). Failures stop the save and say so.

Modes:
- Alarm windows longer than 12 hours work when "until" is later the same
  day ("until 00:00" is the end of that day); they end on the clock across
  a clock change.
- A wall pin survives Home Assistant being away, and a pin made while it
  was away survives it coming back.
- The wall's helper choice is pending until Home Assistant shows it, and
  is sent again when it comes back; a stale or slow read no longer cancels
  the pin.

Also: a 13th widget can't be stacked (notice), stacking/unstacking failures
show a notice, a failed Stack repair retries after a minute, moves and
removes time out after 20 s, and the API docs list the new routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ6Vw8qPecynm2Ho3r8
Stacks on a touch screen:
- A flick no longer turns a Stack when it starts on something that takes
  sideways drags itself: a slider or text field, a list that scrolls
  sideways, an element with touch-action none/pan-y (a graph you scrub), or
  anything marked data-no-flick; nor inside a dialog a stacked widget
  opened. Same rule inside plugin frames (server/utils/pluginFrameTouch.js,
  client/src/utils/flickTarget.js); documented for plugin authors.
- A carry the browser cancels (pointercancel/touchcancel) puts the widget
  back and returns to its tab, instead of dropping it.
- "Bring to front" with no return time: checked again once the Stack has
  been left alone, so an alert that came in just after a touch isn't missed.

Stack data:
- The Stack repair also marks a Stack's widget that's on the tab on its own
  (a deleted tab's Stack landing on Home where it already was, an Admin save
  that didn't finish), so it doesn't show twice; settings kept. A mark for a
  Stack that's gone doesn't block it.
- Admin saves a Stack by id, so a retry after a half-done save doesn't add
  a second copy.
- Stacking on the wall: if placing fails, the new Stack (or the added
  widget) is taken back out of the saved Stacks, read fresh, unless the
  write turns out to have gone through.

Undo and dimming:
- An Undo whose answer is lost but went through finishes (turns the widget
  on, shows the tab) instead of saying it failed.
- A late Undo keeps a Stack mark the widget got since (onlyMissing).
- A mode letting go of its dim goes back to idle dimming only if the screen
  has been idle that long; otherwise it's bright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ6Vw8qPecynm2Ho3r8
The rest of the previous commit, left out of it by mistake:
- A flick inside a dialog or menu a stacked widget opened no longer turns
  the Stack (React passes those touches up to it).
- Tick boxes, radio buttons and buttons don't block a flick (text, range and
  other drag-taking inputs still do); any contenteditable does; pan-left /
  pan-right count as the browser's; a sideways scroller must have a real
  range (over 8 px), so an up-and-down list a pixel too wide doesn't block.
  Same in the plugin-frame copy, which can no longer throw.
- Stacking on the wall: when the placing write got no answer, the tab is
  checked; if it went through, the Stack is kept. A rollback now starts from
  the Stacks as saved now (the new Stack taken out, or the one added to put
  back as it was), so a change made meanwhile isn't lost.
- The Stack repair treats a mark for a Stack that's gone, or doesn't hold
  the widget, as no mark, so such a widget doesn't show twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FyTJ6Vw8qPecynm2Ho3r8
@jherforth

Copy link
Copy Markdown
Owner

@jasonstantler Rechecked on 18a777b. Server tests (764), client tests (1031), check:i18n and the build pass.

1 (a Stack made live breaks the page) is fixed. In Edge, holding Photos on Chores makes the Stack with no "Maximum update depth" errors, and none come later. The Stack stays one card wide, nothing is left floating, and dragging it onto a new tab in the dock moves it with both widgets (their settings kept, still marked in_stack). My full run passes 22 of 22, up from 18.

2 (the display-mode Home Assistant routes) is unchanged, so that's what's left before merge:

  • POST /api/devices/<any>/modes/ha/helper with { "entity_id": "input_select.<anything>" } still runs set_options on that helper with no PIN, and still makes a device row for a name that doesn't exist.
  • PATCH /api/devices/<name>/settings still accepts displayModes.ha.helper / helperId from the client, after which /modes/ha/sync and /modes/ha/select write to that helper.

The fix suggested last time still fits: keep the stored displayModes.ha when the settings PATCH arrives, check the admin PIN on /modes/ha/helper (as /api/plugin/v1/ha/:pluginId/action does: checkAdminPin unless the display remembers it), and return 404 for an unknown device. Once that's in, I'll recheck and merge.

🤖 Generated with Claude Code

@jherforth
jherforth merged commit 8a91f67 into jherforth:main Nov 6, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Nov 6, 2026
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Nov 6, 2026
Resolve conflict in client/src/app.jsx dock hidden prop: keep both
the carry-drag exception from jherforth#378 and the dialog-open hide from
this PR, per jherforth's review on jherforth#383.
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Nov 7, 2026
…rforth#382, jherforth#384) and origin/test/all-combined into test/all-combined

Upstream: Stacks/display Modes/takeovers (jherforth#378), routine complete-all
(jherforth#380), mirror re-measure (jherforth#381), shared New Chore dialog (jherforth#382), admin
PIN chooses the mode helper (jherforth#384). Fork side: Open-Meteo provider,
precipitation probability, bonus-chore return to pool, Google Tasks
no-due-date fix, dock-hide-on-dialog, HA panel test seed.

Conflicts (fork's jherforth#380/jherforth#382 branches vs upstream's merged versions):
took upstream's refinements (scheduleOnly, tickRemainingStepsOrThrow);
app.jsx combines both (dock stays hidden while dragging is false and a
dialog is open).

Auto-seed now strips the HA mode-helper link from seeded settings, so a
new display never inherits another display's helper (jherforth#384: only the
admin PIN chooses it). The Stacks tests pre-create their devices in
before(), while no device has widgets yet, so auto-seed leaves them
empty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants